'use client'; import { useRouter } from 'next/navigation'; import { useActionState, useEffect, useState } from 'react'; import { FoxButton } from '@fox/core/components/fox-button'; import { FoxButtonGroup } from '@fox/core/components/fox-button-group'; import { FoxButtonPanel } from '@fox/core/components/fox-button-panel'; import { FoxCard } from '@fox/core/components/fox-card'; import { FoxDescriptionList } from '@fox/core/components/fox-description-list'; import { FoxHelperText } from '@fox/core/components/fox-helper-text'; import { FoxInput } from '@fox/core/components/fox-input'; import { FoxPageHeader } from '@fox/core/components/fox-page-header'; import { FoxSelect } from '@fox/core/components/fox-select'; import { submitFormAction } from '@/app/_hooks/submit-form-action'; import { useBlockingAction } from '@/app/_hooks/use-blocking-action'; import { useFeedback } from '@/app/_hooks/use-feedback'; import type { CommonCode } from '@/lib/domain/common-code'; import { MERCHANT_DETAIL_VISIBILITY_OPTIONS, MERCHANT_VOUCHER_USE_OPTIONS, formatMerchantDetailStatusLabel, formatOptionalValue, type MerchantDetail, type MerchantDetailSummary, } from '@/lib/domain/merchant-detail'; import { CLOSED_DAYS_MAX_LENGTH, DESCRIPTION_MAX_LENGTH, INITIAL_MERCHANT_DETAIL_FORM_STATE, SORT_ORDER_MAX, } from '@/lib/domain/merchant-detail-form'; import { MERCHANT_DETAILS_PATH, merchantDetailPath } from '@/lib/domain/merchant-detail-query'; import type { MerchantProduct } from '@/lib/domain/merchant-product'; import { formatPhoneNumber } from '@/lib/domain/phone-number'; import { deleteMerchantDetailAction, saveMerchantDetailAction, updateMerchantDetailVisibilityAction, } from '../../_actions'; import { MerchantChoiceChips } from '../../_components/merchant-choice-chips'; import { MerchantImageField } from '../../_components/merchant-image-field'; import { MerchantProductTable } from '../../_components/merchant-product-table'; import styles from './merchant-detail-form.module.scss'; interface MerchantDetailFormProps { /** 상세가 있는 가맹점 — 고친다. 없으면 등록이다. */ detail?: MerchantDetail; /** 등록 — 아직 상세가 없는 가맹점 선택지. 업체명 자리에서 하나를 고른다. */ candidates?: MerchantDetailSummary[]; products?: MerchantProduct[]; categories: CommonCode[]; } /** * 가맹몰 상세 — 기획 BO-MAL-002(등록)·003(수정)을 한 화면으로 합쳤다(사용자 확정). `detail`이 있으면 * 고치고, 없으면 업체명 자리에서 상세가 없는 가맹점을 골라 처음 만든다 — 백엔드 목록·단건이 상세 있는 * 가맹점만 다루므로(feature/voc) 등록은 이 선택으로만 들어온다. 첫 저장 뒤에는 그 가맹점 주소로 옮겨 * 가되 화면 구성은 그대로라 바로 메뉴를 붙일 수 있고, 이후 저장은 그 자리에 머문다. * * 업체명·연락처·우편번호·주소는 가맹점 원본이라 읽기 전용이다. 기획의 [주소 검색]은 가맹점 관리 * 화면의 것이라 여기서는 값을 바꾸지 않는다. */ export function MerchantDetailForm({ detail, candidates = [], products = [], categories, }: MerchantDetailFormProps) { const router = useRouter(); const { showAlert, hideAlert, showToast } = useFeedback(); const [isBusy, startAction] = useBlockingAction(); const [state, formAction, isPending] = useActionState( saveMerchantDetailAction, INITIAL_MERCHANT_DETAIL_FORM_STATE ); const isEdit = detail !== undefined; const errors = state.status === 'error' ? (state.errors ?? {}) : {}; const [frcsNo, setFrcsNo] = useState(detail?.id ?? ''); // 읽기 전용 칸의 출처 — 수정은 상세, 등록은 고른 가맹점. const merchant: MerchantDetailSummary | null = detail ?? candidates.find((row) => row.id === frcsNo) ?? null; const [categoryCode, setCategoryCode] = useState(detail?.categoryCode ?? ''); const [visibility, setVisibility] = useState([ detail?.isVisible === false ? 'N' : 'Y', ]); const [voucherUse, setVoucherUse] = useState(['Y']); useEffect(() => { if (state.status !== 'success') return; if (state.created) { showToast({ variant: 'success', message: '등록했습니다. 이어서 메뉴를 추가할 수 있습니다.' }); router.replace(merchantDetailPath(state.frcsNo)); } else { showToast({ variant: 'success', message: '저장했습니다.' }); router.refresh(); } }, [state, router, showToast]); function toggleVisibility() { if (!detail) return; const next = detail.isVisible === false; startAction(async () => { const result = await updateMerchantDetailVisibilityAction(detail.id, next); showToast( result.status === 'error' ? { variant: 'danger', message: result.message } : { variant: 'success', message: next ? '노출로 바꿨습니다.' : '비활성으로 바꿨습니다.' } ); }); } function runDelete() { if (!detail) return; hideAlert(); startAction(async () => { const result = await deleteMerchantDetailAction(detail.id); if (result.status === 'error') { showToast({ variant: 'danger', message: result.message }); return; } showToast({ variant: 'success', message: '삭제했습니다.' }); router.push(MERCHANT_DETAILS_PATH); }); } function confirmDelete() { if (!detail) return; showAlert({ variant: 'danger', title: '가맹몰 상세를 삭제하시겠습니까?', message: `"${detail.name}"의 상세 정보를 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`, actions: ( ), }); } return ( <>
submitFormAction(event, formAction)} className={styles.form} >
{detail ? ( ) : ( <> ({ value: row.id, label: row.name }))} value={frcsNo} onValueChange={setFrcsNo} error={Boolean(errors.frcsNo)} /> {errors.frcsNo && } {candidates.length === 0 && ( )} )}
({ value: code.code, label: code.label }))} value={categoryCode} onValueChange={setCategoryCode} error={Boolean(errors.categoryCode)} /> {errors.categoryCode && }
{/* 저장으로 파일 id가 바뀌면 다시 마운트해 칸 상태를 서버 값으로 되돌린다. */} {detail && ( Boolean(value)) .join(' / ') || '-', }, { key: 'status', term: '현재 상태', description: formatMerchantDetailStatusLabel(detail) }, ]} /> )} {state.status === 'error' && state.message && ( )} {detail && ( <> )} router.push(MERCHANT_DETAILS_PATH)} /> ); }